Skip to content

test: verify real Whisper inference on NVIDIA T4 - #9

Merged
hydrangeas20 merged 4 commits into
mainfrom
test/whisper-t4-verification
Oct 25, 2026
Merged

hydrangeas20 merged 4 commits into
mainfrom
test/whisper-t4-verification

Conversation

@hydrangeas20

@hydrangeas20 hydrangeas20 commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Verified on an NVIDIA T4. All 8 real-model tests passed with faster-whisper base.en on CUDA FP16 (Colab Tesla T4, commit 096a034). The recorder reported complete: true with no problems. The evidence is committed unmodified in docs/benchmarks/whisper-t4/. This is one verified configuration; see the limitations below.

Problem

The offline suite (fake and gated backends, run in CI) is extensive, but the only real-model test, tests/test_whisper.py, loaded base.en on CPU with int8, decoded silence, and checked that the result was a string. It did not check CUDA, FP16, transcript correctness, error handling, the server, or the WebSocket path, and it had not been confirmed as executed in the current verification cycle.

Implementation

tests/test_whisper.py: 8 tests, marker whisper, still deselected by default. Each level claims only what it exercises.

Level Test Asserts
Adapter …loads_with_requested_device_and_compute_type Device and compute type CTranslate2 reports in effect match the request; on CUDA exactly float16, so a silent fallback fails
Adapter …decodes_silence_and_empty_audio Partial and final settings; empty audio → ""
Adapter …transcribes_real_speech 5 reference utterances, non-empty final and partial-settings text, final corpus WER ≤ 0.12
Adapter …final_decode_is_deterministic Same audio twice → identical final text
Adapter …wraps_decode_errors A failure inside faster-whisper is raised as InferenceError
Server test_server_loads_real_model_from_settings Real uvicorn server, model built and loaded by the lifespan from Settings; /health reports whisper / base.en / device / compute type
WebSocket test_websocket_streams_real_speech Real client streams a 10.9 s utterance (100 ms frames, 2× real time): ready, ≥1 non-empty partial with consecutive seq, exactly one final, no error, close 1000; final text equals a direct adapter decode of the same audio; WER ≤ 0.25
Server test_server_shutdown_releases_inference_workers After a decode, stopping the server stops its thread, the infer* worker threads exit, and the port is released

Fixtures and thresholds:

  • Audio. The speech tests need python -m benchmarks.prepare_audio --num 5 and check each file's PCM SHA-256 against the recorded Phase 1 precision run, so WER is computed on the audio whose FP16 WER was recorded.
  • Thresholds are regression guards, not measurements. Recorded T4 FP16 values on that audio are 0.0708 corpus WER (8/113, precision_t4.json) and 0.1154 for the streamed utterance (3/26, gpu_smoke.json).
  • Device selection. ECHORELAY_TEST_DEVICE=cuda selects the GPU. A CUDA request with no visible device fails; it does not skip.

benchmarks/whisper_verification.py (new, standard library + existing helpers): runs python -m pytest -m whisper -rA -s tests/test_whisper.py and writes record.json, pytest.log and junit.xml. The record holds:

  • UTC start and finish times, and the git commit with its dirty flag
  • full nvidia-smi output, GPU and driver
  • Python and package versions
  • model, device and compute type
  • the exact command
  • any PYTEST_ADDOPTS / PYTHONPATH / ECHORELAY_MODEL_DIR overrides, so a stubbed run cannot pass for a real one
  • per-test outcomes, with recorded WER as JUnit properties

complete requires, and problems lists every violation of:

  • exactly the 8 expected tests (by name) passed, with 0 failures, errors and skips, and pytest exited 0
  • the identity the loaded runtime reported during the run matches the request, rather than trusting the recorded settings:
    • faster_whisper.transcribe.WhisperModel over ctranslate2._ext.Whisper
    • the CTranslate2 device and compute type (exactly float16 on CUDA)
    • at least one CUDA device
    • the models--Systran--faster-whisper-base.en snapshot faster-whisper resolved, with the model.bin SHA-256
    • an English-only model
  • nvidia-smi lists a T4 for CUDA (--expect-gpu)
  • no PYTEST_ADDOPTS / PYTHONPATH override, and a clean checkout

tests/test_whisper_verification.py (32 offline tests, run in CI) covers each rule.

tests/test_observability.py: macOS flake fix.

  • What failed: test_runner_counts_timeouts_while_queued (CI run 40155745468, attempt 1: in_flight was 1 after await asyncio.sleep(0.4)).
  • Cause: a timing assumption, not a cleanup bug. The timed-out decode keeps its slot until its 0.5 s worker thread returns (documented runner behaviour), and the slot is then released on the loop. So the fixed sleep left ~100 ms of slack. With a simulated extra wake-up delay, the old assertion failed at 110 and 150 ms, while the slot was still released 10–53 ms later with free == 1. The scheduler correctly skips the timed-out waiter.
  • The fix:
    • The test uses GatedTranscriber, so the test decides when the decode finishes.
    • It asserts the slot is held while the decode runs, waits for the release with a bounded until(), and then asserts in_flight == 0, waiting == 0, free == 1, and that the queued request never ran.
    • runner.shutdown() is in a finally.
  • Kept and verified: the original assertions are kept. The test passes with a 300 ms simulated slow wake-up, and it still fails if the slot is never released (bounded wait fails after 5 s) or is handed to the timed-out waiter.

Evidence and docs (ee4b7f3).

  • docs/benchmarks/whisper-t4/raw/: record.json, pytest.log and junit.xml, committed byte-identical to the returned ZIP, with SHA256SUMS.
    • verify_evidence now checks 4 manifests and 173 files.
    • The files total about 11 KB; no weights, audio or credentials are committed.
    • The only host-specific values are Colab paths and an ephemeral container hostname in junit.xml.
  • docs/benchmarks/whisper-t4/README.md: the result, provenance, the independent checks below, and comparisons with earlier T4 evidence.
  • docs/whisper-verification.md: the claims of each test, verified status, the filled verification log, limitations, and the reproducible two-cell Colab procedure.
    • It also records the troubleshooting incident, taken from the operator's account of the session. The failed attempts' output is not committed.
  • README.md:
    • The GPU-pending statement is replaced with the verified result.
    • Offline test counts are now 269 per configuration with 8 real-model tests deselected (previously 209 with 1 deselected).
    • The evidence inventory now lists 4 manifests and 173 files, and docs links were added.
  • benchmarks/COLAB.md:
    • The stale "nothing has been run on a GPU" statement is gone.
    • Clones now use main instead of the old feat/streaming-mvp branch.
    • The observed cuBLAS 12 issue is documented, and stage 2b runs the recorder without Colab's PYTHONPATH.
  • .gitignore: a narrow exception so the evidence pytest.log can be committed, like the existing one for server.log.

Unchanged:

  • inference, scheduling and the WebSocket protocol
  • existing benchmark data, results and manifests
  • WER thresholds and verification criteria
  • dependencies and the CI workflow
  • no LICENSE

tests/test_whisper.py, benchmarks/whisper_verification.py and src/ are byte-identical to 096a034, the verified commit.

tests/test_units.py: second flake fix (0858899).

  • What happened: on ee4b7f3, a docs-only commit, the macOS job failed once more and passed on rerun.
  • The suspect: test_runner_releases_slot_only_when_thread_finishes has the same defect class as the flake fixed in 096a034. It sleeps a fixed 0.4 s after a timed-out 0.3 s decode, then asserts the slot was released, leaving about 200 ms of slack.
  • Reproduced: with the worker thread returning 0.25 s late, it fails with assert 1 == 0 on in_flight.
  • The fix:
    • It now waits on state with a bounded until(), keeps every assertion, and always shuts the runner down.
    • It passes the slow-wake simulation and 30/30 repeats, and still fails if the slot is never released.
  • Caveat: that run's log isn't readable from this environment, so this is the likeliest match for the failure, not a confirmed one.

Real-model verification (T4)

Run (UTC) 2026-10-25 04:06:41 → 04:06:52
Commit 096a0347b620e4e5a8696cd6288379c3a3ccae77, clean checkout
GPU / driver Tesla T4, 15360 MiB / 580.82.07
Python / packages 3.13.16 / faster-whisper 1.2.1, CTranslate2 4.8.3
Observed runtime faster_whisper.transcribe.WhisperModel over ctranslate2._ext.Whisper, device cuda, compute type float16, 1 CUDA device, English-only, models--Systran--faster-whisper-base.en snapshot 3d3d5dee…
Tests 8 passed, 0 failed, 0 errors, 0 skipped; pytest exit 0
Verdict complete: true, problems: []; no PYTEST_ADDOPTS / PYTHONPATH override
Corpus WER (5 files, final) 0.0531 (bound 0.12)
WebSocket WER (streamed utterance) 0.1154 (bound 0.25); 10 partials, one final

Independent checks on the evidence. These were made on the files themselves, without trusting the recorder's verdict:

  • junit.xml re-parsed reproduces record.json's results exactly.
  • The validator, re-run on the raw inputs, reports no problems.
  • All 8 JUnit test line numbers match the test definitions in tests/test_whisper.py at 096a034, so the suite that ran is the committed one.
  • pytest.log agrees, and its timestamps fall inside the run window.
  • All five transcripts are word for word identical to the Phase 1 FP16 hypotheses (precision_t4.json). That run used the same GPU, driver and library versions.
  • 0.0531 equals the v2 WER committed in Phase 3 for the same five files.

Correction. The suite cited Phase 1's 0.0708 as the recorded baseline, but that was scored with the v1 normalizer. The comparable v2 value is 0.0531, and the docs now say so. The comment in tests/test_whisper.py still says 0.0708, left as is so the verified file stays byte-identical. The bounds themselves are unchanged.

Troubleshooting incident (operator's account; output not committed).

  1. First attempt failed: Library libcublas.so.12 is not found or cannot be loaded.
    • Colab exposed CUDA 13's libcublas.so.13, with LD_LIBRARY_PATH=/usr/lib64-nvidia.
    • The pip-installed libcublas.so.12 lived under dist-packages/nvidia/cublas/lib, outside that path.
  2. Fix: that directory was prepended to LD_LIBRARY_PATH, plus cuDNN 9's directory if present.
  3. Second attempt: all 8 tests passed, but the recorder correctly reported NOT COMPLETE because of Colab's default PYTHONPATH=/env/python. The check was not relaxed.
  4. Final run: with PYTHONPATH removed from the recorder's environment, it exited 0 and reported complete: true.

LD_LIBRARY_PATH is not captured by the record, so the fix appears only in this account.

Offline results (exact)

Local, Linux, no GPU, at 0858899. Apart from tests/test_units.py, the code is identical to 096a034; the rest of the change is docs and evidence.

Check Python 3.11.17 Python 3.13.16
python -m pytest -q -m "not whisper", all 4 flag combinations 269 passed, 8 deselected (each) 269 passed, 8 deselected (each)
python -m pytest -m whisper --collect-only -q 8/277 collected same
python -m benchmarks.verify_evidence OK: 4 manifests, 173 files, 0 problems —
validator on committed evidence no problems —
git diff --check clean —
Markdown links and anchors in changed docs all resolve —

Earlier, at 096a034:

  • test_observability.py and test_whisper_verification.py passed 30/30 repeats on both versions.
  • ruff check --select F,E9 was clean on new and changed files.

The repo has no configured lint or type checker. 269 = 237 existing tests + 32 validator tests.

Pre-GPU harness checks (not model evidence): a stub WhisperModel exercised the harness and recorder; the results are described in docs/whisper-verification.md.

Reproducing

Use the two Colab cells in docs/whisper-verification.md. They include the library-path fix and run the recorder without PYTHONPATH.

Remaining limitations

  • One verified configuration: base.en, CUDA FP16, one Colab T4, one run.
    • Not verified: CPU int8, int8_float16, other model sizes, other GPUs, other driver or library versions.
  • The recorder misses some run conditions.
    • It does not record LD_LIBRARY_PATH, which was required on Colab.
    • pytest auto-loaded the image's installed plugins (typeguard, anyio, langsmith, platformdirs). They appear only in pytest.log. The integrity check covers overrides, not installed entry-point plugins.
    • These are reported, not changed: changing the recorder would make the branch differ from the verified code.
  • Not covered by the suite:
    • GPU memory release (only worker threads and the port are checked)
    • concurrent load (the benchmarks cover it)
  • CI does not run the real-model tests. Re-verification is manual, on Colab. CI does test the recorder's validation rules.
  • The model.bin hash has nothing to compare against. It is recorded for traceability; no independent reference hash exists in the repo.
  • macOS CI flakes:
    • Fixed: test_runner_counts_timeouts_while_queued (096a034, confirmed from the log).
    • Fixed: test_runner_releases_slot_only_when_thread_finishes (0858899, the likeliest match, not confirmed).
    • CI on 0858899 passed 10/10 on the first attempt (run 40163113395).
    • Other tests that pair a fixed sleep or a wall-clock bound with an assertion haven't been fully audited, for example in test_lifecycle.py, test_reliability.py and test_live_server.py.

https://claude.ai/code/session_017DfLG9q5h7eiawGNwaBswq


Generated by Claude Code

hydrangeas20 and others added 4 commits October 24, 2026 22:46
…level

The only real-model test decoded silence on CPU and checked that the result
was a string. Replace it with eight tests (marker `whisper`, still
deselected by default) that separate adapter, server and WebSocket claims:

- adapter: the device and compute type CTranslate2 reports in effect
  (exactly float16 on CUDA), silence and empty audio, real reference speech
  with a corpus-WER regression guard, deterministic final decoding, and
  decode failures surfacing as InferenceError
- server: a real uvicorn server loading the model from Settings and
  reporting it on /health; shutdown stops the inference workers and frees
  the port
- WebSocket: a real client streams reference speech; partials, exactly one
  final, close 1000, and the final equals a direct adapter decode

The speech tests require the recorded Phase 1 audio set (checked by PCM
SHA-256) so WER is comparable to the recorded T4 FP16 values. CUDA requested
without a GPU fails instead of skipping.

benchmarks/whisper_verification.py runs the suite and records UTC time,
commit, nvidia-smi, versions, the exact command, any pytest overrides, and
per-test outcomes. docs/whisper-verification.md documents the suite and the
one-cell Colab procedure; README and COLAB.md point to it.

GPU verification is pending: no real-model run has been made yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DfLG9q5h7eiawGNwaBswq
test_runner_counts_timeouts_while_queued slept a fixed 0.4 s and then
asserted the timed-out decode had released its slot. The decode runs 0.5 s
in a worker thread and the release happens on the loop afterwards, so the
assertion had ~100 ms of slack and failed on a loaded macOS runner (CI run
40155745468, attempt 1: in_flight == 1). Not a cleanup bug: the slot is
always released shortly after and returned to the pool. The test now uses
GatedTranscriber, so the test decides when the decode finishes, waits on
state with a bounded until(), asserts the slot is held while the decode
runs and returned (free == 1) afterwards, and always shuts the runner down.

whisper_verification: complete now requires exactly the 8 expected tests
passing with no failures, errors or skips, and the runtime identity
recorded during the run (model and CTranslate2 classes, device, compute
type, CUDA device count, base.en snapshot path and model.bin hash) to
match the request, a T4 in nvidia-smi for CUDA, no PYTEST_ADDOPTS or
PYTHONPATH override, and a clean checkout. Requested settings are not
treated as evidence. Validator rules are tested offline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DfLG9q5h7eiawGNwaBswq
The real-model suite ran on a Colab Tesla T4 at 096a034: 8 of 8 passed
with faster-whisper base.en on CUDA FP16, and the recorder reported
complete: true with no problems. Corpus WER 0.0531, streamed-utterance
WER 0.1154 (v2 normalizer); bounds unchanged.

- docs/benchmarks/whisper-t4/raw: record.json, pytest.log and junit.xml,
  committed byte-identical to the returned ZIP, with SHA256SUMS (checked
  by verify_evidence: now 4 manifests, 173 files). A README lists the
  independent checks made on them: JUnit re-parsed to the record, the
  validator re-run on raw inputs, every JUnit test line matched to the
  source at the recorded commit, and all five transcripts identical to
  Phase 1's FP16 hypotheses.
- docs/whisper-verification.md: verified status, filled log, limitations,
  and the Colab procedure including the cuBLAS 12 / cuDNN 9 library-path
  fix and running the recorder without Colab's default PYTHONPATH. The
  failed attempts are documented as a troubleshooting incident from the
  operator's account; their output is not committed. Corrects the cited
  baseline: Phase 1's 0.0708 was scored with the v1 normalizer; the
  comparable v2 value is 0.0531.
- README and COLAB.md: remove the GPU-pending statements, update test
  counts (269 offline per configuration) and the evidence inventory, and
  clone main instead of the stale feat/streaming-mvp branch.
- .gitignore: allow the committed evidence pytest.log.

The tests and the recorder are unchanged since 096a034, so the code on
this branch is the code that was verified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DfLG9q5h7eiawGNwaBswq
test_runner_releases_slot_only_when_thread_finishes slept a fixed 0.4 s
after a timed-out 0.3 s decode and then asserted the slot was released.
The release runs on the loop after the worker thread returns, so the
assertion had ~200 ms of slack: the same defect class as the macOS flake
fixed in 096a034. With the worker thread returning 0.25 s late (a loaded
runner), it fails with in_flight == 1. It now waits on state with
gated.until(), keeps every assertion, and always shuts the runner down.

The macOS job failed once more on ee4b7f3 (docs-only change; passed on
rerun). Its log is not readable from this environment, so this test is
the likeliest match for that failure, not a confirmed one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DfLG9q5h7eiawGNwaBswq
@hydrangeas20
hydrangeas20 merged commit be98cd1 into main Oct 25, 2026
10 checks passed
hydrangeas20 added a commit that referenced this pull request Oct 25, 2026
* test: replace fixed sleeps and unbounded polls in lifecycle/reliability tests

Audit of timing-sensitive tests (lifecycle, reliability, live server,
streaming, errors, load-test, observability, priority). Changed only where
fragility was demonstrated; behavioral assertions are unchanged.

- test_lifecycle mid-partial cancellation: a fixed 0.4 s sleep after a
  0.3 s decode asserted the slot was released (~100 ms slack, the macOS
  flake class fixed in PR #9). With the worker thread returning 0.25 s late
  the old test fails (assert 1 == 0); it now waits on state with
  gated.until() and always shuts the runner down.
- test_reliability slow sender: frames every 0.35 s against a 0.5 s idle
  limit left 150 ms of margin; one late client wake-up fires idle_timeout.
  Now 0.1 s of audio every 0.15 s: still slower than real time (1.5x),
  0.35 s margin. Passes with a 0.2 s late wake-up injected; still fails if
  the idle timer stops resetting per frame (whole stream > 0.5 s).
- Five unbounded `while ...: sleep` polls (lifecycle, and the reliability
  final-decode disconnect, queued disconnect, malformed-message and
  shutdown tests) now use bounded condition waits: a regression that
  prevents the awaited state fails in ~5 s with a message instead of
  hanging until the CI job timeout (demonstrated by disabling partial
  scheduling: old versions hang, new fail with "timed out waiting for
  partial decode to start").

Left as is (already bounded, deliberate, or wide margins): live-server
polls, idle/send/load-test wall-clock bounds (2-3 s for 0.3-0.5 s
operations), the event-loop-lag test, gate pumps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DfLG9q5h7eiawGNwaBswq

* feat(verification): record environment provenance; correct WER comment

Recorder (benchmarks/whisper_verification.py): a `provenance` section
that never changes `complete`, separating what the environment said from
what the test process loaded:

- environment_metadata: allowlisted CUDA / loader / pytest variables as
  passed to pytest (LD_LIBRARY_PATH as entries, LD_PRELOAD, CUDA_*,
  NVIDIA_*, CT2_*, PYTEST_DISABLE_PLUGIN_AUTOLOAD), which search-path
  directories hold libcublas.so.12 / libcublasLt.so.12 / libcudnn.so.9,
  and installed nvidia-* wheels. Nothing outside the allowlist is read;
  URL userinfo and secret-looking KEY=value entries are redacted before
  path lists are split. Labelled as metadata, not proof of loading.
- pytest_plugins: pytest11 entry points with distribution versions, and
  the plugins line pytest printed for the run.
- loaded_libraries: CUDA / CTranslate2 shared objects mapped into the
  pytest process after the real-speech decodes (/proc/self/maps), as
  recorded by that test.

PYTEST_PLUGINS is now treated as an override like PYTEST_ADDOPTS and
PYTHONPATH (it loads arbitrary plugin modules): a stricter check, no
existing requirement relaxed.

tests/test_whisper.py: the real-speech test records the loaded CUDA
libraries; the 0.0708 baseline comment is corrected (Phase 1 scored the
same transcripts with the v1 normalizer; the v2 value the tests compute
is 0.0531). WER bounds unchanged.

Offline tests for every new recorder behaviour (allowlist, redaction,
search-path detection, wheels, plugin inventory and header parsing,
loaded-library parsing, provenance not affecting completeness).

Docs: the T4 result stays tied to 096a034; the changed recorder and test
file are marked as not yet re-verified on a GPU. Provenance fields, their
limits, and how to reproduce the verified environment are documented. PR
#9's raw evidence is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DfLG9q5h7eiawGNwaBswq

* fix(verification): close review findings F1-F6 before GPU verification

F1  Unreadable library-path directories no longer crash the recorder:
    Path.exists/is_dir raised PermissionError under an unreadable parent
    (reproduced as a non-root user on 3.11 and 3.13), aborting record.json
    after the tests ran. Each probe now records the error
    (ld_library_path_entries_exist, search_errors). Each provenance section
    is collected independently; one that raises is recorded as
    collection_error and fails the run closed, instead of losing the record.

F2  /proc/self/maps: the pathname is everything after the fifth field, so
    paths with spaces ("My Drive") are kept whole (was: last token only);
    " (deleted)" is dropped. The parser moved to the recorder module and is
    tested offline, including against a real mapping in this process.

F3  Plugin inventory renamed and documented accurately:
    installed_entry_points (what pytest can auto-load) and header_reported
    (pytest's plugins: line, entry-point plugins only). Neither claims to
    list every plugin that ran: -p, PYTEST_PLUGINS, conftest.py and
    pytest_plugins routes are covered by the override and checkout rules.

F4  Redaction: URL userinfo is removed up to the last "@" (passwords with
    "@" or "/" previously leaked, in part or whole); secret-like KEY=value
    assignments are redacted anywhere, including query strings; common token
    formats (GitHub, Hugging Face, AWS, sk-, Slack, GitLab, JWT) are
    redacted. Documented as best effort: the allowlist remains the main
    control and unknown formats are not caught.

F5  Checkout audit (fail closed), taken before pytest starts: any untracked
    non-ignored file anywhere, or any ignored *.py/*.pyc/*.pyo/*.pth/*.so or
    pytest/Python config file directly in the repo root, tests/ or src/,
    makes the run incomplete; an audit failure does too. git status
    --untracked-files=no (the dirty check) let an untracked root conftest.py
    run inside the tests while reporting a clean checkout; reproduced, and
    now reported as "untracked files present (1): ['conftest.py']".

F6  LD_PRELOAD is split on spaces and colons, mixed; the lifecycle test
    cancels its session task if a bounded wait fails.

Existing requirements (exact 8 tests, model identity, WER bounds 0.12 /
0.25, clean commit, overrides) are unchanged. No production code,
dependency, CI or raw-evidence change. Not yet re-verified on a GPU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DfLG9q5h7eiawGNwaBswq

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant